Skip to content

Conversation

@Grit03
Copy link
Owner

@Grit03 Grit03 commented Jan 24, 2026

What?

Make Server Actions CSRF origin validation case-insensitive for both:

  1. Host / X-Forwarded-Host header comparison with Origin
  2. serverActions.allowedOrigins config matching

Why?

Per RFC 3986 Section 3.2.2 states the host component of URIs is case-insensitive:

"...host is case-insensitive, producers and normalizers should use lowercase for registered names..."

Per RFC 3986 Section 6.2.2.1 reinforces:

"...the scheme and host are case-insensitive and therefore should be normalized to lowercase."

(per RFC 2119) While the RFC recommends ("should") that producers normalize to lowercase, this is not mandatory. In practice, proxies may preserve the original case of Host/X-Forwarded-Host headers. Since upstream normalization is not guaranteed, the application(Next.js) performing the comparison must handle case-insensitive matching.

Current behavior in the existing code

In the current implementation, normalization via the JavaScript URL API is applied only to the Origin header value. Meanwhile, Host/X-Forwarded-Host headers retain their original casing, which can lead to false-positive CSRF blocks when proxies forward headers with different casing (e.g., Example.com vs example.com).

How?

  • Normalize Host and X-Forwarded-Host to lowercase in parseHostHeader()
  • Add defensive case-insensitive comparison in origin/host matching helper
  • (Additional) Normalize both origin and patterns to lowercase in isCsrfOriginAllowed()
    • Similar to the Origin vs Host casing mismatch, this also fixes case mismatches in serverActions.allowedOrigins values from next.config.js.

Tests

1. E2E test for Host/X-Forwarded-Host case mismatch (host-origin-case-insensitive)

To reproduce the real-world scenario where Origin and X-Forwarded-Host have different casing, added a proxy-based e2e test. The proxy sets:

  • Origin: https://example.com (lowercase, as normalized by URL API)
  • X-Forwarded-Host: Example.com (mixed case, simulating proxy behavior)

This test failed before the fix due to case-sensitive comparison, now passes.

2. E2E test for allowedOrigins config case mismatch (config-match-case-insensitive)

Added an e2e test where next.config.js specifies serverActions.allowedOrigins: ['Example.COM'] while the actual origin header is https://example.com. This verifies that the config matching is also case-insensitive.

3. Unit tests

  • action-handler.test.ts: Tests for parseHostHeader() lowercasing and isOriginMatchingHost() case-insensitive comparison
  • csrf-protection.test.ts: Tests for isCsrfOriginAllowed() case-insensitive matching between origin and allowedOrigins patterns

To reproduce (before the fix)

You can confirm the previous behavior (case-sensitive matching) by checking out the commits below and running the corresponding e2e tests.

1) Origin vs Host / X-Forwarded-Host case mismatch (e2b569a)

✅ Reproduction (video + logs)
2026-01-24.5.32.06.mov
 GET / 200 in 394ms (compile: 103ms, proxy.ts: 113ms, render: 178ms)
`x-forwarded-host` header with value `Example.com` does not match `origin` header with value `example.com` from a forwarded Server Actions request. Aborting the action.
⨯ Error: Invalid Server Actions request.
    at ignore-listed frames {
  digest: '1743102819@E80'
}
 POST / 500 in 171ms (compile: 2ms, proxy.ts: 3ms, render: 166ms)
[browser] Uncaught Error: Invalid Server Actions request.
git checkout e2b569a
pnpm test-dev-turbo test/e2e/app-dir/actions-allowed-origins/app-action-host-match-case-insensitive.test.ts

2) serverActions.allowedOrigins config case mismatch (a2d6510)

✅ Reproduction (video + logs)
2026-01-24.5.36.35.mov
 GET / 200 in 606ms (compile: 280ms, proxy.ts: 124ms, render: 202ms)
`x-forwarded-host` header with value `localhost:3000` does not match `origin` header with value `example.com` from a forwarded Server Actions request. Aborting the action.
⨯ Error: Invalid Server Actions request.
    at ignore-listed frames {
  digest: '3375356869@E80'
}
 POST / 500 in 201ms (compile: 4ms, proxy.ts: 3ms, render: 193ms)
[browser] Uncaught Error: Invalid Server Actions request.
git checkout a2d6510
pnpm test-dev-turbo test/e2e/app-dir/actions-allowed-origins/app-action-config-match-case-insensitive.test.ts

Fixes #issue

Grit03 added 19 commits January 24, 2026 14:26
…n for server action

- use proxy to set a lowercase origin and a mixed-case x-forwarded-host to reproduce case-sensitive validation
- use fixed origin and x-forwarded-host values so the test reproduces consistently across environments
- include minimal app-dir fixtures (layout/page/action/form/const)
- cover scenario where server action CSRF validation incorrectly depends on header casing
- trigger the server action via a button click
- capture the server action response and assert the response status is 200
- get the submitted message from the action response and render it in the DOM
- verify the DOM message matches the original payload to confirm the action executed successfully
…-insensitive

- Clarifies that this test validates case-insensitive Origin vs Host matching
- Introduced ORIGIN_DOMAIN and X_FORWARDED_HOST constants to support case-insensitive checks in the ClientForm component.
- Updated ClientForm to display current origin and X-Forwarded-Host values.
- Changed ORIGIN_DOMAIN to include 'https://' for accurate origin header setting.
- Updated proxy function to use the modified ORIGIN_DOMAIN directly for consistency in origin handling.
- Eliminated console logging of 'origin', 'host', and 'x-forwarded-host' to streamline the proxy function and reduce unnecessary output.
- Replace example-domain.com with example.com (RFC 2606) to ensure the
test domain never conflicts with a real website.
…der case

- Describe missing origin header case (not host) in action handler.
…Forwarded-Host headers

- Convert Host and X-Forwarded-Host header values to lowercase for consistent comparison
…ility

- Separate origin/host matching logic into a helper to make it easier to test
- Keep explicit `!host` check in the conditional for readability even though the helper also guards
- Cover case-insensitive origin/host matching for Host and X-Forwarded-Host headers
- Include a negative case where the host does not match the origin
- add case-insensitive matching for origin vs host
- add case-insensitive matching for origin vs x-forwarded-host
- organize existing mismatch case under a named test case
- allow host to be undefined to avoid type errors since it is already guarded upstream
- keep defensive case-insensitive comparison in origin/host matching
…parison

- Add e2e test to ensure that the `serverActions.allowedOrigins` config option
matches origins case-insensitively.
- Set the origin header to
'https://example.com' while configuring allowedOrigins as ['Example.COM']
to verify that different cases are treated as matching.
- Ensure that variations in case for both the origin and allowedOrigins are correctly recognized as matches.
- Normalize both the origin and allowedOrigins to lowercase for consistent comparison.
@Grit03
Copy link
Owner Author

Grit03 commented Jan 24, 2026

In Korean Version

What?

Server Actions의 CSRF Origin 검증을 대소문자 구분 없이(case-insensitive) 동작하도록 개선합니다.
대상은 두 가지입니다.

  1. Host / X-Forwarded-Host 헤더를 Origin과 비교하는 로직
  2. serverActions.allowedOrigins 설정값 매칭 로직

Why?

[RFC 3986 Section 3.2.2](https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.2)에 따르면 URI의 host 컴포넌트는 대소문자를 구분하지 않습니다.

“…host is case-insensitive, producers and normalizers should use lowercase for registered names…”

또한 [RFC 3986 Section 6.2.2.1](https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.2.1)에서도 동일하게 강조합니다.

“…the scheme and host are case-insensitive and therefore should be normalized to lowercase.”

([RFC 2119](https://datatracker.ietf.org/doc/html/rfc2119) 기준) RFC는 host를 소문자로 정규화하라고 “should”로 권고하지만, 이는 필수(must) 는 아닙니다.
실제로 프록시가 Host/X-Forwarded-Host 헤더의 원래 대소문자를 그대로 보존해서 전달하는 경우가 있을 수 있습니다.

즉, 업스트림(프록시)에서의 정규화를 신뢰할 수 없으므로, 비교를 수행하는 애플리케이션(Next.js)에서 대소문자 구분 없이 처리해야 합니다.

Current behavior (현재 구현의 문제)

현재 구현에서는 JavaScript URL API를 통해 Origin 헤더 값만 정규화합니다.
반면 Host/X-Forwarded-Host 헤더는 원래 casing을 유지합니다.

이 때문에 프록시가 서로 다른 casing으로 헤더를 전달하면(예: Example.com vs example.com)
동일 출처 요청인데도 CSRF 검증이 실패하는 false-positive 차단이 발생할 수 있습니다.

How?

  • parseHostHeader()에서 HostX-Forwarded-Host소문자로 정규화합니다.

  • Origin ↔ Host 비교 헬퍼에서 방어적으로(case-insensitive) 비교하도록 보강합니다.

  • (추가) isCsrfOriginAllowed()에서 origin과 패턴 모두를 소문자로 정규화해 비교합니다.

    • Origin vs Host 케이스 문제와 유사하게, next.config.jsserverActions.allowedOrigins 값에서도 casing 불일치로 인한 매칭 실패를 방지합니다.

Tests (테스트)

Host/X-Forwarded-Host casing 불일치 e2e (host-origin-case-insensitive)

실제 환경에서 OriginX-Forwarded-Host의 casing이 달라질 수 있는 상황을 재현하기 위해,
프록시 기반 e2e 테스트를 추가했습니다. 프록시는 아래처럼 헤더를 설정합니다.

  • Origin: https://example.com (URL API 정규화로 소문자)
  • X-Forwarded-Host: Example.com (프록시가 casing을 보존하는 상황 시뮬레이션)

이 테스트는 수정 전에는 대소문자 민감 비교로 인해 실패했으나, 수정 후에는 통과합니다.

allowedOrigins 설정 casing 불일치 e2e (config-match-case-insensitive)

next.config.jsserverActions.allowedOrigins: ['Example.COM']로 설정하고,
실제 Originhttps://example.com으로 들어오는 케이스를 e2e로 추가했습니다.

설정값 매칭도 대소문자 구분 없이 처리됨을 검증합니다.

유닛 테스트

  • action-handler.test.ts

    • parseHostHeader()가 소문자로 정규화하는지
    • isOriginMatchingHost()가 case-insensitive 비교를 수행하는지
  • csrf-protection.test.ts

    • isCsrfOriginAllowed()가 origin과 allowedOrigins 패턴 간 case-insensitive 매칭을 수행하는지

수정 전 재현 방법 (before the fix)

아래 커밋으로 체크아웃한 뒤, 해당 e2e 테스트를 실행하면 기존 동작(대소문자 구분 매칭) 을 확인할 수 있습니다.

1) Origin vs Host / X-Forwarded-Host case mismatch (e2b569a)

✅ 재현 (video + logs)
2026-01-24.5.32.06.mov
 GET / 200 in 394ms (compile: 103ms, proxy.ts: 113ms, render: 178ms)
`x-forwarded-host` header with value `Example.com` does not match `origin` header with value `example.com` from a forwarded Server Actions request. Aborting the action.
⨯ Error: Invalid Server Actions request.
    at ignore-listed frames {
  digest: '1743102819@E80'
}
 POST / 500 in 171ms (compile: 2ms, proxy.ts: 3ms, render: 166ms)
[browser] Uncaught Error: Invalid Server Actions request.
git checkout e2b569a
pnpm test-dev-turbo test/e2e/app-dir/actions-allowed-origins/app-action-host-match-case-insensitive.test.ts

2) serverActions.allowedOrigins config case mismatch (a2d6510)

✅ 재현 (video + logs)
2026-01-24.5.36.35.mov
 GET / 200 in 606ms (compile: 280ms, proxy.ts: 124ms, render: 202ms)
`x-forwarded-host` header with value `localhost:3000` does not match `origin` header with value `example.com` from a forwarded Server Actions request. Aborting the action.
⨯ Error: Invalid Server Actions request.
    at ignore-listed frames {
  digest: '3375356869@E80'
}
 POST / 500 in 201ms (compile: 4ms, proxy.ts: 3ms, render: 193ms)
[browser] Uncaught Error: Invalid Server Actions request.
git checkout a2d6510
pnpm test-dev-turbo test/e2e/app-dir/actions-allowed-origins/app-action-config-match-case-insensitive.test.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant